Skip to content

[Enhancement] Signed webhook event system: HMAC-signed payment lifecy… - #57

Open
Johnsource-hub wants to merge 1 commit into
Deen-Bridge:mainfrom
Johnsource-hub:main
Open

[Enhancement] Signed webhook event system: HMAC-signed payment lifecy…#57
Johnsource-hub wants to merge 1 commit into
Deen-Bridge:mainfrom
Johnsource-hub:main

Conversation

@Johnsource-hub

@Johnsource-hub Johnsource-hub commented Jul 23, 2026

Copy link
Copy Markdown

#close
#45

📝 Summary
Introduces a robust, decoupled outbound webhook/event delivery system. This allows external consumers (e.g., DeenBridge AI, analytics, educator tooling) to subscribe to key platform lifecycle events (e.g., payment.confirmed, course.enrolled) over secure, signed HTTP callbacks.

Deliveries are signed via HMAC-SHA256 (Stripe-style), include exponential backoff retries with jitter, transition to a dead-letter state upon repeated failures, support automated endpoint disabling, and expose a management API for endpoint and delivery lifecycle management.

🎯 Context & Motivation
Previously, the platform emitted no outbound notifications for payment state transitions or course enrollments—state updates were logged internally and required external consumers to poll REST endpoints. This PR establishes an asynchronous, machine-to-machine event layer without introducing tight coupling or blocking HTTP request paths.

🏗️ What Was Built

  1. Database Models
    WebhookEndpoint (src/models/WebhookEndpoint.js):

Stores registration data (url, secret, events, isActive, description).

Enforces HTTPS outside development.

Secrets are stored using SHA-256 hashes (secretHash) and are shown only once upon registration or rotation.

Includes auto-disable metadata (consecutiveFailures, disabledAt, disabledReason).

WebhookDelivery (src/models/WebhookDelivery.js):

Tracks delivery attempts per event per endpoint (pending, delivered, retrying, dead).

Stores bounded attempt histories (at, statusCode, error, durationMs) and a snapshot of the payload.

Bounded payload and error string fields to protect database storage.

Summary by CodeRabbit

  • New Features

    • Added webhook management for creating, updating, deleting, and securing endpoints.
    • Added event subscriptions for course enrollment, wallet, and payment activity.
    • Added delivery monitoring, retry handling, redelivery, endpoint testing, and automatic failure protection.
    • Added signed webhook payloads and secure endpoint URL validation.
  • Bug Fixes

    • Prevented webhook background processing from running during tests.
  • Tests

    • Added comprehensive coverage for webhook APIs, signatures, delivery retries, security, and event filtering.

…cle callbacks with retries and dead-letter redelivery
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds a signed webhook system with endpoint management, HMAC payloads, asynchronous delivery, retries, dead-letter redelivery, SSRF validation, and payment, enrollment, and wallet event integrations.

Changes

Webhook system

Layer / File(s) Summary
Webhook contracts and security
src/models/WebhookEndpoint.js, src/models/WebhookDelivery.js, src/services/webhooks/webhookService.js, src/utils/ssrfGuard.js
Defines endpoint and delivery schemas, event catalogs, secret hashing, HMAC signing, deterministic serialization, subscription matching, delivery persistence, and SSRF-safe URL validation.
Webhook management API
src/controllers/webhookController.js, src/routes/webhookRoutes.js
Adds authenticated endpoint CRUD, secret rotation, delivery listing and redelivery, ping, and event-type listing handlers.
Delivery worker and lifecycle processing
src/services/webhooks/deliveryWorker.js
Claims due deliveries atomically, sends signed requests, records attempts, applies retry backoff, marks terminal failures, and auto-disables failing endpoints.
Event wiring and application startup
app.js, src/controllers/courses/courseController.js, src/controllers/stellar/paymentController.js, src/controllers/stellar/walletController.js
Mounts webhook routes, starts the worker outside tests, and emits lifecycle events after enrollment, payment, and wallet state changes.
Webhook integration and behavior validation
test/webhook-api.test.js, test/webhooks.test.js
Validates API authorization and management operations, signatures, payload filtering, event emission, worker concurrency, retries, endpoint disabling, SSRF rules, and event metadata.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Controller
  participant webhookService
  participant WebhookDelivery
  participant deliveryWorker
  participant Endpoint
  Controller->>webhookService: emitEvent(eventType, data)
  webhookService->>WebhookDelivery: insert pending delivery
  deliveryWorker->>WebhookDelivery: claim due delivery
  deliveryWorker->>Endpoint: POST signed payload
  Endpoint-->>deliveryWorker: HTTP response
  deliveryWorker->>WebhookDelivery: update delivery status and attempts
Loading

Possibly related PRs

  • Deen-Bridge/dnb-backend#41 — Both changes modify payment controller lifecycle handling around initialization, failure, and confirmation paths.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning [#45] The webhook system is broadly implemented, but the required consumer-signature README documentation is not reflected in the change summary. Add README guidance for timestamped HMAC verification, stale-timestamp rejection, and a copy-paste Node verifier snippet.
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: a signed webhook event system for payment lifecycles.
Out of Scope Changes check ✅ Passed The changes stay focused on webhook delivery, management, security, and tests; no unrelated features are evident.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🧹 Nitpick comments (7)
src/services/webhooks/deliveryWorker.js (1)

109-119: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Nice touch on the auto-disable safety valve — let's just not copy-paste it. 🙂

The exact same auto-disable block (set isActive=false, disabledAt, disabledReason, log warn) is repeated verbatim in the non-2xx branch here and again in the catch branch at Lines 145-154. Duplicated logic like this tends to drift over time (someone fixes one path and forgets the other), so extracting a tiny helper keeps both failure paths honest.

♻️ Suggested helper extraction
+async function maybeAutoDisableEndpoint(endpoint) {
+  if (endpoint.consecutiveFailures < MAX_CONSECUTIVE_FAILURES_TO_DISABLE) return;
+  endpoint.isActive = false;
+  endpoint.disabledAt = new Date();
+  endpoint.disabledReason = `Auto-disabled after ${endpoint.consecutiveFailures} consecutive delivery failures`;
+  await endpoint.save();
+  logger.warn(
+    { endpointId: endpoint._id, consecutiveFailures: endpoint.consecutiveFailures },
+    "Webhook endpoint auto-disabled due to sustained failures"
+  );
+}

Then call await maybeAutoDisableEndpoint(endpoint); in both branches.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/webhooks/deliveryWorker.js` around lines 109 - 119, Extract the
repeated auto-disable logic from the non-2xx and catch failure paths into a
shared async helper, such as maybeAutoDisableEndpoint, preserving the existing
threshold check, endpoint updates, save operation, and warning log. Replace both
duplicated blocks with await maybeAutoDisableEndpoint(endpoint) so both branches
use the same behavior.
app.js (1)

184-193: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Router mount + test-guarded worker start look correct. The NODE_ENV !== "test" guard matches the .env.example contract (worker must not run background jobs under test).

One optional follow-up: webhookWorker holds a stop() handle but I don't see it invoked on shutdown (SIGTERM/SIGINT). Wiring it into graceful shutdown avoids leaked timers/half-sent deliveries during redeploys, if you have a shutdown hook elsewhere.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app.js` around lines 184 - 193, Wire the `webhookWorker` stop handle into the
existing graceful shutdown path for SIGTERM/SIGINT, invoking `stop()` before
process termination. Preserve the current `NODE_ENV !== "test"` startup guard
and ensure shutdown remains safe when `webhookWorker` is null.
src/controllers/webhookController.js (2)

11-20: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Unused helper: requireAdmin is never called.

Every handler enforces authorization inline via req.user.role !== "admin" && endpoint.owner.toString() !== ..., so this function is dead code. Either wire it up where admin-only behavior is intended, or remove it to avoid confusing future maintainers about which authorization pattern is actually enforced.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/webhookController.js` around lines 11 - 20, Remove the unused
requireAdmin helper from the controller, since authorization is currently
enforced inline by the handlers. Do not alter the existing inline authorization
checks or introduce a second authorization pattern.

270-281: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Unbounded/unvalidated pagination inputs.

page/limit come straight from req.query with no numeric validation or upper bound. A non-numeric value produces NaN for skip/limit (likely a driver error caught as a generic 500), and there's nothing preventing limit=100000+ from doing a very large scan/transfer.

♻️ Suggested guard
-    const { status, page = 1, limit = 20 } = req.query;
+    const { status } = req.query;
+    const page = Math.max(1, parseInt(req.query.page, 10) || 1);
+    const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 20));
     const query = { endpoint: endpoint._id };
     if (status) query.status = status;
 
-    const skip = (parseInt(page) - 1) * parseInt(limit);
+    const skip = (page - 1) * limit;
     const [deliveries, total] = await Promise.all([
       WebhookDelivery.find(query)
         .sort({ createdAt: -1 })
         .skip(skip)
-        .limit(parseInt(limit)),
+        .limit(limit),
       WebhookDelivery.countDocuments(query),
     ]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/webhookController.js` around lines 270 - 281, Validate and
normalize the page and limit inputs in the delivery query flow before
calculating skip, rejecting non-numeric or non-positive values and enforcing a
reasonable maximum limit. Update the pagination logic around
WebhookDelivery.find and countDocuments so invalid requests receive the
controller’s established client-error response, while valid requests continue
using the normalized values for skip and limit.
src/services/webhooks/webhookService.js (1)

120-133: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Dead/buggy helper — not used by the delivery worker and contains a placeholder bug.

buildDeliveryHeaders sets "X-DeenBridge-Event": undefined (to be "set per-delivery") and never sets X-DeenBridge-Event-Id. Per the supplied deliveryWorker.js context snippet, the actual delivery worker builds its headers manually inline rather than calling this function, so this exported helper appears to be dead/duplicated logic that has drifted out of sync with the real header set (missing the event-id header) and would need extra wiring to actually work if someone picked it up later.

Recommend either removing this function or fixing it to match the real header contract and having deliveryWorker.js call it, so there's a single source of truth for delivery headers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/webhooks/webhookService.js` around lines 120 - 133, The exported
buildDeliveryHeaders helper is unused and contains an undefined event header
while omitting the event-id header. Remove this dead helper, or make it the
single source of truth by matching the delivery worker’s complete header
contract and updating deliveryWorker.js to call it for every delivery.
src/utils/ssrfGuard.js (1)

27-37: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Minor: IPv6 private-range detection is too coarse, and isIPv6Loopback is unused.

ip.startsWith("fc") || ip.startsWith("fe") (Line 29) will match any IPv6 address in fe00::feff::, not just the intended fe80::/10 link-local range, so some legitimate public-ish IPv6 literals could be rejected. This fails safe (over-blocks) so it's not exploitable, just imprecise. Also isIPv6Loopback (Lines 35-37) is defined but never called.

Only checking resolve4 also means an endpoint hostname that only has an AAAA record has no IPv6-specific private-range check at all (it just fails DNS resolution and gets rejected as invalid, which is safe but not correct for legitimate IPv6-only hosts).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/ssrfGuard.js` around lines 27 - 37, The IPv6 classification in
isPrivateOrLoopback is overly broad and leaves isIPv6Loopback unused. Replace
prefix checks with precise IPv6 range validation for fc00::/7 and fe80::/10,
reuse isIPv6Loopback where appropriate, and update hostname resolution to
inspect AAAA records alongside resolve4 so valid IPv6-only hosts receive the
same private-range checks.
src/models/WebhookDelivery.js (1)

61-62: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Add a compound index covering the listDeliveries sort pattern.

webhookController.js's listDeliveries filters by { endpoint, status? } and sorts by createdAt: -1. The existing { endpoint: 1, status: 1 } index doesn't cover the sort, so Mongo will need an in-memory sort once an endpoint accumulates many delivery records.

♻️ Suggested additional index
 webhookDeliverySchema.index({ endpoint: 1, status: 1 });
 webhookDeliverySchema.index({ status: 1, nextAttemptAt: 1 });
+webhookDeliverySchema.index({ endpoint: 1, createdAt: -1 });

As per path instructions, "Flag schema changes that break existing documents, missing indexes for new query patterns, and TTL indexes that could delete durable records."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/models/WebhookDelivery.js` around lines 61 - 62, Update
webhookDeliverySchema indexes to add a compound index matching the
listDeliveries query pattern: endpoint and optional status equality filters
followed by createdAt descending sort. Preserve the existing indexes and ensure
the new index supports efficient sorted retrieval without an in-memory sort.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/controllers/stellar/paymentController.js`:
- Line 436: Remove the newly added paymentsConfirmed.inc({ type: "purchase" })
call from the post-commit purchase flow in the payment controller. Keep the
existing increment after the confirmed transaction.save unchanged so each
confirmed purchase is counted once.

In `@src/controllers/webhookController.js`:
- Around line 349-376: Update pingEndpoint to create a WebhookDelivery targeted
directly at endpoint._id instead of calling the subscription-based emitEvent
broadcast. Ensure the delivery is created independently of payment.initialized
or wildcard subscriptions, while preserving the existing authorization, success
response, and error handling behavior.
- Around line 200-223: Align deleteEndpoint with the documented behavior by
limiting WebhookDelivery.deleteMany to pending deliveries only, using the
model’s existing pending-status field/value. Preserve the endpoint deletion and
authorization flow, or, if all delivery records are intentionally removed,
update the deleteEndpoint docstring to explicitly state that all deliveries are
deleted.
- Around line 26-43: Update all webhook controller handlers, including
listEndpoints, createEndpoint, listDeliveries, listEventTypes, getEndpoint,
updateEndpoint, deleteEndpoint, rotateSecret, and redeliver, to return the
mandated { success, message, data } envelope. Move endpoint payloads, counts,
secrets, deliveries, pagination, and events under data, while preserving
existing success status, messages, and error behavior; add or update Jest
coverage in test/webhook-api.test.js for the consistent response shapes.

In `@src/models/WebhookEndpoint.js`:
- Around line 19-23: Update the WebhookEndpoint secret persistence used by
createEndpoint, rotateSecret, and deliveryWorker so the platform can recover the
same raw secret consumers receive when generating outbound signatures. Replace
the one-way SHA-256-only storage with a server-recoverable representation, and
ensure signing reads that recoverable value while preserving select:false
protection.

In `@src/services/webhooks/deliveryWorker.js`:
- Around line 168-194: Update processDeliveries to implement a visibility lease
for claimed deliveries: stamp a claim timestamp when setting status to
"processing", and expand the selection criteria to include processing deliveries
whose lease has expired. Ensure the delivery schema/model persists the lease
field and use the existing timeout configuration or constant to determine
expiration, so crashed or failed workers can retry orphaned deliveries.

In `@src/services/webhooks/webhookService.js`:
- Around line 139-141: Update serializePayload to deep-sort the payload object
before calling JSON.stringify, rather than passing the top-level sorted keys as
the replacer array. Preserve all nested fields, including those under data,
while maintaining deterministic key ordering for the serialized body and
signature input.

In `@src/utils/ssrfGuard.js`:
- Around line 16-19: Update ipToNumber to validate input with net.isIP or an
equivalent strict IPv4 parser before conversion, returning null for hostnames,
IPv6 addresses, malformed dotted values, and non-IPv4 literals. Preserve numeric
conversion only for valid IPv4 literals so validateEndpointUrl continues to
perform DNS checks for hostnames and invalid IP-like inputs.

In `@test/webhook-api.test.js`:
- Line 8: Update the JWT_SECRET setup in the test to require
process.env.JWT_SECRET and fail fast when it is missing, removing the hardcoded
fallback. Ensure the test uses the same environment-provided secret as the app’s
protect middleware and does not introduce any inline secret.
- Around line 32-35: Update the test setup and teardown hooks around beforeEach
and afterAll to also clear the User collection with the existing collection
cleanup calls. Keep WebhookEndpoint and WebhookDelivery cleanup unchanged so
each test and the suite teardown reset all three collections.

In `@test/webhooks.test.js`:
- Around line 371-379: Ensure each affected SSRF test restores
process.env.NODE_ENV in a finally block around its validation and assertions.
Apply this to test/webhooks.test.js ranges 371-379, 392-402, 404-412, and
414-424; preserve each test’s existing setup, validation, and expectations while
moving restoration into finally so it runs on failures.
- Around line 366-369: Update the test case around validateEndpointUrl so its
requireHttps option matches the intended HTTPS-required scenario described by
the test title, while preserving the existing URL and validity assertion.
- Around line 251-283: Make the retry, dead, and auto-disable test cases
deterministic by stubbing axios.post in test/webhooks.test.js at lines 251-283,
285-323, and 325-356 to reject or return a non-2xx response; remove the
ECONNREFUSED-specific comment and ensure each site’s existing failure assertions
remain unchanged.

---

Nitpick comments:
In `@app.js`:
- Around line 184-193: Wire the `webhookWorker` stop handle into the existing
graceful shutdown path for SIGTERM/SIGINT, invoking `stop()` before process
termination. Preserve the current `NODE_ENV !== "test"` startup guard and ensure
shutdown remains safe when `webhookWorker` is null.

In `@src/controllers/webhookController.js`:
- Around line 11-20: Remove the unused requireAdmin helper from the controller,
since authorization is currently enforced inline by the handlers. Do not alter
the existing inline authorization checks or introduce a second authorization
pattern.
- Around line 270-281: Validate and normalize the page and limit inputs in the
delivery query flow before calculating skip, rejecting non-numeric or
non-positive values and enforcing a reasonable maximum limit. Update the
pagination logic around WebhookDelivery.find and countDocuments so invalid
requests receive the controller’s established client-error response, while valid
requests continue using the normalized values for skip and limit.

In `@src/models/WebhookDelivery.js`:
- Around line 61-62: Update webhookDeliverySchema indexes to add a compound
index matching the listDeliveries query pattern: endpoint and optional status
equality filters followed by createdAt descending sort. Preserve the existing
indexes and ensure the new index supports efficient sorted retrieval without an
in-memory sort.

In `@src/services/webhooks/deliveryWorker.js`:
- Around line 109-119: Extract the repeated auto-disable logic from the non-2xx
and catch failure paths into a shared async helper, such as
maybeAutoDisableEndpoint, preserving the existing threshold check, endpoint
updates, save operation, and warning log. Replace both duplicated blocks with
await maybeAutoDisableEndpoint(endpoint) so both branches use the same behavior.

In `@src/services/webhooks/webhookService.js`:
- Around line 120-133: The exported buildDeliveryHeaders helper is unused and
contains an undefined event header while omitting the event-id header. Remove
this dead helper, or make it the single source of truth by matching the delivery
worker’s complete header contract and updating deliveryWorker.js to call it for
every delivery.

In `@src/utils/ssrfGuard.js`:
- Around line 27-37: The IPv6 classification in isPrivateOrLoopback is overly
broad and leaves isIPv6Loopback unused. Replace prefix checks with precise IPv6
range validation for fc00::/7 and fe80::/10, reuse isIPv6Loopback where
appropriate, and update hostname resolution to inspect AAAA records alongside
resolve4 so valid IPv6-only hosts receive the same private-range checks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3a3ca31b-1c8b-4ad6-ba5d-b3ab2d7abfcf

📥 Commits

Reviewing files that changed from the base of the PR and between aebaa00 and 219664a.

📒 Files selected for processing (13)
  • app.js
  • src/controllers/courses/courseController.js
  • src/controllers/stellar/paymentController.js
  • src/controllers/stellar/walletController.js
  • src/controllers/webhookController.js
  • src/models/WebhookDelivery.js
  • src/models/WebhookEndpoint.js
  • src/routes/webhookRoutes.js
  • src/services/webhooks/deliveryWorker.js
  • src/services/webhooks/webhookService.js
  • src/utils/ssrfGuard.js
  • test/webhook-api.test.js
  • test/webhooks.test.js


await buyer.save({ session });
await session.commitTransaction();
paymentsConfirmed.inc({ type: "purchase" });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Payment metric is being double-counted here. 📊

paymentsConfirmed.inc({ type: "purchase" }) already runs on Line 401 (right after the confirmed transaction.save), and this newly-added line increments the same counter a second time after commit. Every confirmed purchase will now count as two, which quietly corrupts dashboards and any alerting/SLOs built on paymentsConfirmed. Since the pre-commit increment on Line 401 is already in place, drop this one.

🐛 Suggested fix
     await session.commitTransaction();
-    paymentsConfirmed.inc({ type: "purchase" });
 
     // Emit event after commit — fire-and-forget
     emitEvent("payment.confirmed", {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
paymentsConfirmed.inc({ type: "purchase" });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/stellar/paymentController.js` at line 436, Remove the newly
added paymentsConfirmed.inc({ type: "purchase" }) call from the post-commit
purchase flow in the payment controller. Keep the existing increment after the
confirmed transaction.save unchanged so each confirmed purchase is counted once.

Comment on lines +26 to +43
export const listEndpoints = async (req, res) => {
try {
const query = req.user.role === "admin"
? {}
: { owner: req.user._id };

const endpoints = await WebhookEndpoint.find(query).sort({ createdAt: -1 });

res.status(200).json({
success: true,
count: endpoints.length,
endpoints,
});
} catch (error) {
logger.error({ err: error }, "List webhook endpoints error");
res.status(500).json({ success: false, message: "Failed to list endpoints" });
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Response shapes don't follow the mandated { success, message, data } contract.

Nearly every handler returns ad hoc top-level keys instead of nesting the payload under data, e.g. listEndpoints{ success, count, endpoints } (Line 34-38), createEndpoint{ success, message, endpoint, secret } (Line 95-107), listDeliveries{ success, deliveries, pagination } (Line 283-292), listEventTypes{ success, events } (Line 383-386), and so on through getEndpoint, updateEndpoint, deleteEndpoint, rotateSecret, and redeliver.

Consistent envelopes matter here because test/webhook-api.test.js (the layer that depends on this one) and any future consumers will need to know exactly where to look for the payload; deviating per-endpoint invites brittle client code.

As per path instructions, "New or changed endpoints need Jest coverage ... and consistent response shapes ({ success, message, data })."

Also applies to: 118-135, 141-198, 204-223, 229-253, 259-297, 299-343, 378-387

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/webhookController.js` around lines 26 - 43, Update all
webhook controller handlers, including listEndpoints, createEndpoint,
listDeliveries, listEventTypes, getEndpoint, updateEndpoint, deleteEndpoint,
rotateSecret, and redeliver, to return the mandated { success, message, data }
envelope. Move endpoint payloads, counts, secrets, deliveries, pagination, and
events under data, while preserving existing success status, messages, and error
behavior; add or update Jest coverage in test/webhook-api.test.js for the
consistent response shapes.

Source: Path instructions

Comment on lines +200 to +223
/**
* Delete an endpoint and its pending deliveries.
* DELETE /api/webhooks/:id
*/
export const deleteEndpoint = async (req, res) => {
try {
const endpoint = await WebhookEndpoint.findById(req.params.id);
if (!endpoint) {
return res.status(404).json({ success: false, message: "Endpoint not found" });
}

if (req.user.role !== "admin" && endpoint.owner.toString() !== req.user._id.toString()) {
return res.status(403).json({ success: false, message: "Forbidden" });
}

await WebhookDelivery.deleteMany({ endpoint: endpoint._id });
await endpoint.deleteOne();

res.status(200).json({ success: true, message: "Endpoint deleted" });
} catch (error) {
logger.error({ err: error }, "Delete webhook endpoint error");
res.status(500).json({ success: false, message: "Failed to delete endpoint" });
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Docstring/behavior mismatch: this deletes all deliveries, not just pending ones.

The comment says "Delete an endpoint and its pending deliveries" but WebhookDelivery.deleteMany({ endpoint: endpoint._id }) (Line 215) removes every delivery regardless of status — including successfully delivered history. If delivery records are meant to serve as an audit trail (dispute resolution, debugging), permanently wiping delivered/dead records on endpoint deletion may not be desired; if it is intended, please update the comment to match.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/webhookController.js` around lines 200 - 223, Align
deleteEndpoint with the documented behavior by limiting
WebhookDelivery.deleteMany to pending deliveries only, using the model’s
existing pending-status field/value. Preserve the endpoint deletion and
authorization flow, or, if all delivery records are intentionally removed,
update the deleteEndpoint docstring to explicitly state that all deliveries are
deleted.

Comment on lines +349 to +376
export const pingEndpoint = async (req, res) => {
try {
const endpoint = await WebhookEndpoint.findById(req.params.id);
if (!endpoint) {
return res.status(404).json({ success: false, message: "Endpoint not found" });
}

if (req.user.role !== "admin" && endpoint.owner.toString() !== req.user._id.toString()) {
return res.status(403).json({ success: false, message: "Forbidden" });
}

// Emit a ping event — will be delivered like any other event
await emitEvent("payment.initialized", {
ping: true,
message: "DeenBridge webhook ping",
endpointId: endpoint._id.toString(),
sentAt: new Date().toISOString(),
});

res.status(200).json({
success: true,
message: "Ping event emitted",
});
} catch (error) {
logger.error({ err: error }, "Ping webhook endpoint error");
res.status(500).json({ success: false, message: "Failed to send ping" });
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Major: pingEndpoint doesn't actually target the endpoint being pinged — it broadcasts to every subscriber of payment.initialized.

emitEvent (imported from webhookService.js) fans an event out to all active endpoints subscribed to that event type or "*" — it has no concept of "deliver only to this one endpoint." So POST /:id/ping:

  • Will not deliver anything to the target endpoint if it isn't subscribed to payment.initialized (or wildcard) — yet the handler still responds { success: true, message: "Ping event emitted" }, misleading the caller into thinking their integration was exercised.
  • Will deliver the ping (including endpointId: endpoint._id.toString(), which identifies the endpoint someone else pinged) to every other active endpoint across all tenants that happens to subscribe to payment.initialized or "*". That's unsolicited webhook traffic and a small cross-tenant information leak (another user's ping request reveals your endpoint's ObjectId to their consumer).

The ping should create a WebhookDelivery targeted directly at endpoint._id, independent of that endpoint's event subscriptions, rather than going through the subscription-based broadcast path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/webhookController.js` around lines 349 - 376, Update
pingEndpoint to create a WebhookDelivery targeted directly at endpoint._id
instead of calling the subscription-based emitEvent broadcast. Ensure the
delivery is created independently of payment.initialized or wildcard
subscriptions, while preserving the existing authorization, success response,
and error handling behavior.

Comment on lines +19 to +23
secret: {
type: String,
required: true,
select: false,
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## File list\n'
git ls-files 'src/models/WebhookEndpoint.js' 'src/controllers/webhookController.js' 'src/workers/deliveryWorker.js' 'src/**/*.js' | sed -n '1,200p'

printf '\n## Outline: src/models/WebhookEndpoint.js\n'
ast-grep outline src/models/WebhookEndpoint.js --view expanded || true

printf '\n## Outline: src/controllers/webhookController.js\n'
ast-grep outline src/controllers/webhookController.js --view expanded || true

printf '\n## Outline: src/workers/deliveryWorker.js\n'
ast-grep outline src/workers/deliveryWorker.js --view expanded || true

printf '\n## Search secret-related symbols\n'
rg -n "generateSecret|verifySecret|computeSignature|secret" src/models/WebhookEndpoint.js src/controllers/webhookController.js src/workers/deliveryWorker.js

Repository: Deen-Bridge/dnb-backend

Length of output: 5453


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# If the first file paths differ, locate the actual files and print compact context.
fd -a 'WebhookEndpoint\.js|webhookController\.js|deliveryWorker\.js' src 2>/dev/null || true

Repository: Deen-Bridge/dnb-backend

Length of output: 325


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read only the relevant regions once the files are located.
for f in src/models/WebhookEndpoint.js src/controllers/webhookController.js src/workers/deliveryWorker.js; do
  if [ -f "$f" ]; then
    printf '\n### %s\n' "$f"
    nl -ba "$f" | sed -n '1,220p'
  fi
done

Repository: Deen-Bridge/dnb-backend

Length of output: 236


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the target files and print the exact relevant lines only.
for f in $(fd -a '^WebhookEndpoint\.js$|^webhookController\.js$|^deliveryWorker\.js$' src 2>/dev/null); do
  printf '\n### %s\n' "$f"
  nl -ba "$f" | sed -n '1,220p'
done

Repository: Deen-Bridge/dnb-backend

Length of output: 262


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for f in /home/jailuser/git/src/models/WebhookEndpoint.js \
         /home/jailuser/git/src/controllers/webhookController.js \
         /home/jailuser/git/src/services/webhooks/deliveryWorker.js; do
  echo "### $f"
  sed -n '1,260p' "$f" | cat -n
  echo
done

Repository: Deen-Bridge/dnb-backend

Length of output: 21737


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' /home/jailuser/git/src/services/webhooks/webhookService.js | cat -n

Repository: Deen-Bridge/dnb-backend

Length of output: 5251


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "timestamped HMAC|HMAC|computeSignature|X-DeenBridge-Signature|webhook" /home/jailuser/git/src -g '*.js'

Repository: Deen-Bridge/dnb-backend

Length of output: 6359


Webhook signatures need a recoverable secret
src/models/WebhookEndpoint.js stores only a SHA-256 hash, but src/services/webhooks/deliveryWorker.js signs with that persisted value. Since createEndpoint / rotateSecret return the raw secret only once, consumers get one secret while the platform signs with another. Store a server-recoverable secret for signing, or outbound signatures can never match what consumers compute.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/models/WebhookEndpoint.js` around lines 19 - 23, Update the
WebhookEndpoint secret persistence used by createEndpoint, rotateSecret, and
deliveryWorker so the platform can recover the same raw secret consumers receive
when generating outbound signatures. Replace the one-way SHA-256-only storage
with a server-recoverable representation, and ensure signing reads that
recoverable value while preserving select:false protection.

Comment thread test/webhook-api.test.js
import WebhookEndpoint from "../src/models/WebhookEndpoint.js";
import WebhookDelivery from "../src/models/WebhookDelivery.js";

const JWT_SECRET = process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Move the JWT secret out of source and confirm it matches the app. A hardcoded fallback secret in a .js file is exactly what we want to keep out of the repo, and this one also has two practical hazards:

  1. The tokens signed here are only accepted by the protect middleware if the app is verifying with the same secret. If JWT_SECRET is unset in CI, this test falls back to "deenbridge-temp-secret-key-2024" while app.js may fall back to something else → every authenticated request returns 401.
  2. Per .env.example, JWT_SECRET requires a minimum of 32 chars; this fallback is 31, so it would fail that documented constraint.

Prefer requiring the env var (fail fast) rather than embedding a secret:

🔐 Suggested change
-const JWT_SECRET = process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024";
+const JWT_SECRET = process.env.JWT_SECRET;
+if (!JWT_SECRET) {
+  throw new Error("JWT_SECRET must be set for webhook API tests");
+}

As per path instructions: "Flag hardcoded secrets, connection strings, JWT secrets, or wallet keys; all configuration belongs in environment variables documented in .env.example."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const JWT_SECRET = process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024";
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
throw new Error("JWT_SECRET must be set for webhook API tests");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/webhook-api.test.js` at line 8, Update the JWT_SECRET setup in the test
to require process.env.JWT_SECRET and fail fast when it is missing, removing the
hardcoded fallback. Ensure the test uses the same environment-provided secret as
the app’s protect middleware and does not introduce any inline secret.

Source: Path instructions

Comment thread test/webhook-api.test.js
Comment on lines +32 to +35
beforeEach(async () => {
await WebhookEndpoint.deleteMany({});
await WebhookDelivery.deleteMany({});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

CI blocker: the User collection is never reset between tests, so repeated fixture creation will throw duplicate-key errors. 👋 Welcome, and thanks for the thorough test coverage!

beforeEach (and afterAll) clean WebhookEndpoint and WebhookDelivery, but not User. Yet almost every test does User.create({ _id: adminUserId, email: "admin@test.com", ... }) (and the GET tests reuse regularUserId / user@test.com). Because _id and email are uniquely indexed, the second test to insert the same document fails with an E11000 duplicate key error, cascading into failures across the suite. The first test happens to pass, which can make this easy to miss locally if you only run one test.

Reset User alongside the other collections so each test starts from a clean slate:

🧹 Proposed fix
 beforeEach(async () => {
+  const User = (await import("../src/models/User.js")).default;
+  await User.deleteMany({});
   await WebhookEndpoint.deleteMany({});
   await WebhookDelivery.deleteMany({});
 });

And mirror it in afterAll (Line 26-30) to avoid leaking fixtures into other suites:

 afterAll(async () => {
+  const User = (await import("../src/models/User.js")).default;
+  await User.deleteMany({});
   await WebhookEndpoint.deleteMany({});
   await WebhookDelivery.deleteMany({});
   await mongoose.disconnect();
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
beforeEach(async () => {
await WebhookEndpoint.deleteMany({});
await WebhookDelivery.deleteMany({});
});
beforeEach(async () => {
const User = (await import("../src/models/User.js")).default;
await User.deleteMany({});
await WebhookEndpoint.deleteMany({});
await WebhookDelivery.deleteMany({});
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/webhook-api.test.js` around lines 32 - 35, Update the test setup and
teardown hooks around beforeEach and afterAll to also clear the User collection
with the existing collection cleanup calls. Keep WebhookEndpoint and
WebhookDelivery cleanup unchanged so each test and the suite teardown reset all
three collections.

Comment thread test/webhooks.test.js
Comment on lines +251 to +283
it("failed delivery transitions to retrying with future nextAttemptAt", async () => {
const endpoint = await WebhookEndpoint.create({
url: "https://example.com/hook",
secret: "hashed",
events: ["payment.confirmed"],
owner: new mongoose.Types.ObjectId(),
});

const payload = {
eventId: "test-event-retry",
eventType: "payment.confirmed",
apiVersion: WEBHOOK_API_VERSION,
createdAt: new Date().toISOString(),
data: { transactionId: "tx-retry" },
};
await WebhookDelivery.create({
endpoint: endpoint._id,
eventId: "test-event-retry",
eventType: "payment.confirmed",
payload,
status: "pending",
nextAttemptAt: new Date(),
});

// Process — axios is not mocked here so it will fail (ECONNREFUSED)
await processDeliveries(1);

const delivery = await WebhookDelivery.findOne({ eventId: "test-event-retry" });
expect(delivery.status).toBe("retrying");
expect(delivery.attempts).toHaveLength(1);
expect(delivery.attempts[0].at).toBeDefined();
expect(delivery.nextAttemptAt.getTime()).toBeGreaterThan(Date.now());
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -HI 'jest.config*'
fd -HI axios -p '**/__mocks__/**'
rg -nP "jest\.mock\(\s*['\"].*axios" -g '!node_modules'
rg -nP "setupFiles(AfterEnv)?" -A2 package.json jest.config.* 2>/dev/null

Repository: Deen-Bridge/dnb-backend

Length of output: 279


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## jest.config.js\n'
cat -n jest.config.js

printf '\n## axios mock files\n'
fd -HI 'axios*' .
fd -HI '__mocks__' .

printf '\n## axios jest mocks / stubs\n'
rg -n "jest\.mock\\(.*axios|__mocks__/axios|from ['\"].*axios['\"]|require\\(['\"].*axios['\"]\\)" .

printf '\n## webhook delivery implementation\n'
rg -n "deliverWebhook|processDeliveries|axios\.post|nextAttemptAt|consecutiveFailures|disabledReason|isActive" -S .

Repository: Deen-Bridge/dnb-backend

Length of output: 5603


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '## jest.config.js'
sed -n '1,220p' jest.config.js

echo
echo '## mock files'
find . -path '*/__mocks__/*' -type f | sort

echo
echo '## axios references'
rg -n --hidden --glob '!node_modules' --glob '!dist' --glob '!build' --glob '!coverage' "jest\.mock\(['\"].*axios|from ['\"].*axios['\"]|require\(['\"].*axios['\"]\)|__mocks__/.+axios|axios\.post" .

echo
echo '## webhook-related implementation'
rg -n --hidden --glob '!node_modules' "processDeliveries|deliverWebhook|nextAttemptAt|consecutiveFailures|disabledReason|isActive" .

Repository: Deen-Bridge/dnb-backend

Length of output: 5559


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

root = Path('.')
patterns = [
    re.compile(r"jest\.mock\(['\"].*axios"),
    re.compile(r"__mocks__.*/axios"),
    re.compile(r"from ['\"].*axios['\"]"),
    re.compile(r"require\(['\"].*axios['\"]\)"),
    re.compile(r"\baxios\.post\b"),
]
files = []
for p in root.rglob('*'):
    if not p.is_file():
        continue
    if any(part in {'.git', 'node_modules', 'dist', 'build', 'coverage'} for part in p.parts):
        continue
    if p.suffix not in {'.js', '.ts', '.jsx', '.tsx', '.json'}:
        continue
    try:
        text = p.read_text(encoding='utf-8')
    except Exception:
        continue
    if any(pt.search(text) for pt in patterns):
        files.append(p)

for p in files:
    print(p)
PY

echo
echo '## jest config references'
rg -n --hidden --glob '!node_modules' "setupFiles|setupFilesAfterEnv|globalSetup|globalTeardown|jest\.mock|axios" jest.config.js package.json test .github . 2>/dev/null || true

Repository: Deen-Bridge/dnb-backend

Length of output: 2230


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '## src/services/webhooks/deliveryWorker.js'
sed -n '1,240p' src/services/webhooks/deliveryWorker.js

echo
echo '## test/webhooks.test.js (imports and relevant section)'
sed -n '1,120p' test/webhooks.test.js
echo
sed -n '240,370p' test/webhooks.test.js

Repository: Deen-Bridge/dnb-backend

Length of output: 15465


Mock axios.post in test/webhooks.test.js:251-356. These cases still make live requests to https://example.com, so the retry/dead/auto-disable paths depend on DNS and the remote response. Because deliverWebhook treats any 2xx as success, a real 200 would make these assertions fail. Stub the call to reject or return a non-2xx so the suite stays deterministic; the ECONNREFUSED note is too specific.

📍 Affects 1 file
  • test/webhooks.test.js#L251-L283 (this comment)
  • test/webhooks.test.js#L285-L323
  • test/webhooks.test.js#L325-L356
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/webhooks.test.js` around lines 251 - 283, Make the retry, dead, and
auto-disable test cases deterministic by stubbing axios.post in
test/webhooks.test.js at lines 251-283, 285-323, and 325-356 to reject or return
a non-2xx response; remove the ECONNREFUSED-specific comment and ensure each
site’s existing failure assertions remain unchanged.

Comment thread test/webhooks.test.js
Comment on lines +366 to +369
it("accepts https URLs when requireHttps is true (skipping DNS)", async () => {
const result = await validateEndpointUrl("https://example.com/hook", { requireHttps: false });
expect(result.valid).toBe(true);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Test title contradicts the option under test. The name says "when requireHttps is true (skipping DNS)", but the call passes { requireHttps: false }, so this case never actually exercises the requireHttps: true HTTPS-accepted path — it just confirms a valid https URL passes when the check is off. Either flip the option to true or rename the test so the intent is clear; otherwise a regression in the requireHttps: true accept path would slip through.

✏️ Option A — test what the title claims
-  it("accepts https URLs when requireHttps is true (skipping DNS)", async () => {
-    const result = await validateEndpointUrl("https://example.com/hook", { requireHttps: false });
+  it("accepts https URLs when requireHttps is true (skipping DNS)", async () => {
+    const result = await validateEndpointUrl("https://example.com/hook", { requireHttps: true });
     expect(result.valid).toBe(true);
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("accepts https URLs when requireHttps is true (skipping DNS)", async () => {
const result = await validateEndpointUrl("https://example.com/hook", { requireHttps: false });
expect(result.valid).toBe(true);
});
it("accepts https URLs when requireHttps is true (skipping DNS)", async () => {
const result = await validateEndpointUrl("https://example.com/hook", { requireHttps: true });
expect(result.valid).toBe(true);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/webhooks.test.js` around lines 366 - 369, Update the test case around
validateEndpointUrl so its requireHttps option matches the intended
HTTPS-required scenario described by the test title, while preserving the
existing URL and validity assertion.

Comment thread test/webhooks.test.js
Comment on lines +371 to +379
it("accepts http URLs in development", async () => {
const original = process.env.NODE_ENV;
process.env.NODE_ENV = "development";

const result = await validateEndpointUrl("http://localhost:3000/hook");
expect(result.valid).toBe(true);

process.env.NODE_ENV = original;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Restore NODE_ENV in a finally block to prevent cross-test leakage. Each of these SSRF tests saves original, overwrites process.env.NODE_ENV, then restores it only on the last line. If any assertion in between fails (or throws), the restore never runs and the mutated NODE_ENV leaks into every subsequent test — turning one failure into a confusing cascade, since SSRF behavior itself depends on NODE_ENV. Wrap the body in try { ... } finally { process.env.NODE_ENV = original; } at each site:

  • test/webhooks.test.js#L371-L379: guard the development / http://localhost case.
  • test/webhooks.test.js#L392-L402: guard the production loopback-literal case.
  • test/webhooks.test.js#L404-L412: guard the production 10.x.x.x case.
  • test/webhooks.test.js#L414-L424: guard the production http-before-IP case.
📍 Affects 1 file
  • test/webhooks.test.js#L371-L379 (this comment)
  • test/webhooks.test.js#L392-L402
  • test/webhooks.test.js#L404-L412
  • test/webhooks.test.js#L414-L424
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/webhooks.test.js` around lines 371 - 379, Ensure each affected SSRF test
restores process.env.NODE_ENV in a finally block around its validation and
assertions. Apply this to test/webhooks.test.js ranges 371-379, 392-402,
404-412, and 414-424; preserve each test’s existing setup, validation, and
expectations while moving restoration into finally so it runs on failures.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant